#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
A _base_ Tiles object for pySlip tiles.

All tile sources should inherit from this base class.
"""

# Copyright (c) 2010, Ross Wilson (rzzzwilson@gmail.com). All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following conditions
# are met:
#  * Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.


import os
import glob
import wx

# if we don't have log.py, don't crash
try:
    import log
    log = log.Log('pyslip.log', log.Log.DEBUG)
except ImportError:
    def log(*args, **kwargs):
        pass


###############################################################################
# An in-memory and disk cache for pySlip tiles.
# Requires a wxPython application to be created before use.
###############################################################################

class TileCache(object):
    """An object that caches tiles."""

    # path to tile in on-disk cache, %params are (x, y)
    TilePath = os.path.join('%d', '%d.png')

    # maximum number of tiles for each level
    MaxTileLevelHolding = 4000

    def __init__(self, cache_dir, levels):
        """Create the tile cache.

        cache_dir  path to a possibly existing directory cache of tiles
        levels     list of level numbers that will be used
        """

        # prepare tile cache if not already there
        if not os.path.isdir(cache_dir):
            if os.path.isfile(cache_dir):
                msg = ("%s doesn't appear to be a tile cache directory"
                       % cache_dir)
                raise Exception(msg)
            os.makedirs(cache_dir)
        for level in levels:
            level_dir = os.path.join(cache_dir, '%d' % level)
            if not os.path.isdir(level_dir):
                os.makedirs(level_dir)
        self.cache_dir = cache_dir

        # setup the tile cache and LRU lists
        self.cache = {}
        self.lru = {}
        for l in levels:
            self.cache[l] = {}
            self.lru[l] = []

    def GetTile(self, level, x, y):
        """Get bitmap for tile at level,x,y.

        level  level to get tile from
        x      X coord of tile required (tile coordinates)
        y      Y coord of tile required (tile coordinates)

        Return tile from the local cache, return None if not found.
        """

        # the tile 'key'
        tile_key = (x, y)

        try:
            # if tile in cache, return it from there
            bitmap = self.cache[level][tile_key]
            self.lru[level].remove((x, y))      # remove, add at recent end
            self.lru[level].insert(0, tile_key)
        except KeyError:
            # tile *not* in memory cache look in disk cache
            tile_dir = os.path.join(self.cache_dir, '%d' % level)
            tile_path = os.path.join(tile_dir, self.TilePath % (x, y))
            if not os.path.exists(tile_path):
                # tile not there, return None
                bitmap = None
            else:
                # we have the tile file - read into memory, cache & return
                image = wx.Image(tile_path, wx.BITMAP_TYPE_ANY)
                bitmap = image.ConvertToBitmap()
                self.cache[level][tile_key] = bitmap
                self.lru[level].insert(0, tile_key)

                # newly cached tile, check if we must drop old cached tiles
                self._trim_cache(level)

        return bitmap

    def CacheTile(self, image, bitmap, level, x, y):
        """Save tile to disk and in-memory cache.

        image   data to save in disk cache file
        bitmap  the tile bitmap to save in LRU cache
        level   level of the tile
        x       X coord of tile
        y       Y coord of tile
        """

        # the tile 'key'
        tile_key = (x, y)

        # put bitmap into the in-memory cache
        self.cache[level][tile_key] = bitmap

        # add this tile at "most recent" end of the LRU list
        self.lru[level].insert(0, tile_key)

        # drop relatively unused tiles from in-memory cache
        self._trim_cache(level)

        # write data to the appropriate disk file
        tile_dir = os.path.join(self.cache_dir, '%d' % level)
        tile_path = os.path.join(tile_dir, self.TilePath % (x, y))
        dir_name = os.path.dirname(tile_path)
        try:
            os.makedirs(dir_name)
        except OSError:
            pass
        image.SaveFile(tile_path, wx.BITMAP_TYPE_PNG)

    def _trim_cache(self, level):
        """Ensure the cache for a level isn't too large."""

        while len(self.lru[level]) > self.MaxTileLevelHolding:
            tile_key = self.lru[level].pop()    # remove old tile from LRU
            try:
                del self.cache[level][tile_key] # and remove from cache
            except KeyError:
                # don't worry about cache/lru mismatches
                pass

    def _get_cache_size(self, level):
        """Return the number of tiles in cache for 'level'"""

        return len(self.cache[level])

######
# Base class for a tile source - handles access to a source of tiles.
######

class Tiles(object):
    """An object to source tiles for pyslip."""

    def __init__(self, tile_cache_dir, tile_levels=None):
        """Initialise a Tiles instance.

        tile_cache_dir  tile cache directory, may contain tiles
        tile_levels     list of tile levels to be served
        """

        # save the tile cache directory
        self.tile_cache_dir = tile_cache_dir

        # look in tile directory for levels if none supplied
        if tile_levels is None:
            glob_pattern = os.path.join(tile_cache_dir, '[0-9]*')
            tile_levels = []
            for p in glob.glob(glob_pattern):
                filename = int(os.path.basename(p))
                tile_levels.append(filename)
            tile_levels.sort()

        # setup the tile cache
        self.cache = TileCache(tile_cache_dir, tile_levels)

        # save the levels to be served
        self.levels = tile_levels

        # set min and max tile levels
        self.min_level = min(self.levels)
        self.max_level = max(self.levels)

        # this function is called when a pending tile becomes available
        # we can leave the callback mechanism in if we don't use it
        # (saves having to define it if we override for net tiles)
        self.available_callback = None

    def SetAvailableCallback(self, callback):
        """Set the "tile now available" callback routine.

        callback  function with signature callback(level, x, y)
                  where 'level' is the level of the tile and 'x' and 'y' are
                  the coordinates of the tile that is now available.
        """

        self.available_callback = callback

    def UseLevel(self, level):
        """Prepare to serve tiles from the required level.

        level  The required level

        Returns a tuple (map_width, map_height, ppd_x, ppd_y) if succesful,
        else None.  The width/height values are pixels.  The ppd_? values are
        pixels-per-degree values for the X and Y directions and are valid only
        in a Cartesian coordinate system.
        """

        if level not in self.levels:
            return None

        # get tile info
        info = self.GetInfo(level)
        if info is None:            # level not used
            return None
        (self.num_tiles_x, self.num_tiles_y, self.ppd_x, self.ppd_y) = info

        # set level we are currently serving
        self.level = level

        # store partial path to level dir
        self.tile_level_dir = os.path.join(self.tile_cache_dir, '%d' % level)

        # finally, return new level info
        return (self.tile_size_x * self.num_tiles_x,
                self.tile_size_y * self.num_tiles_y,
                self.ppd_x, self.ppd_y)

    def GetTile(self, x, y):
        """Get bitmap for tile at tile coords (x, y) and current level.

        x  X coord of tile required (tile coordinates)
        y  Y coord of tile required (tile coordinates)

        Returns bitmap object for the tile image.

        Tile coordinates are measured from map top-left.
        """

        return self.cache.GetTile(self.level, x, y)

    def GetInfo(self, level):
        """Get tile info for a particular level.

        level  the level to get tile info for

        Returns (num_tiles_x, num_tiles_y, ppd_x, ppd_y).
        """

        raise Exception('You must override Tiles.GetInfo()')

    def ConvertGeo2View(self, lonlat, view_offset_x, view_offset_y, view_llon,
                        view_tlat, ppd_x, ppd_y):
        """Convert a geo (lon+lat) position to view pixel coords.

        lonlat         (longitude, latitude) of point
        view_offset_x  is pixels from left edge of map to edge of view
        view_offset_y  is pixels from top edge of map to edge of view
        view_llon      longitude of left edge of view
        view_tlat      latitude of top edge of view
        ppd_x          pixels per degree in X direction (cartesian only)
        ppd_y          pixels per degree in Y direction (cartesian only)

        Return screen pixels coordinates of the point (x,y).
        """

        raise Exception('You must override Tiles.ConvertGeo2View()')

    def ConvertView2Geo(self, xy, view_offset_x, view_offset_y, map_llon,
                        map_tlat, ppd_x, ppd_y):
        """Convert an x,y view position to geo lon+lat.

        xy             tuple of view X & Y coordinate (pixels)
        view_offset_x  is pixels from left edge of map to edge of view
        view_offset_y  is pixels from top edge of map to edge of view
        map_llon       longitude of left edge of map
        map_tlat       latitude of top edge of map
        ppd_x          pixels per degree in X direction (cartesian only)
        ppd_y          pixels per degree in Y direction (cartesian only)

        Return a tuple (lon, lat) - geo coordinates of the point.
        """

        raise Exception('You must override Tiles.ConvertView2Geo()')
